/**
 * Licensed to Apereo under one or more contributor license agreements. See the NOTICE file
 * distributed with this work for additional information regarding copyright ownership. Apereo
 * licenses this file to you under the Apache License, Version 2.0 (the "License"); you may not use
 * this file except in compliance with the License. You may obtain a copy of the License at the
 * following location:
 *
 * <p>http://www.apache.org/licenses/LICENSE-2.0
 *
 * <p>Unless required by applicable law or agreed to in writing, software distributed under the
 * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
 * express or implied. See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apereo.portal.portlet.container.services;

import java.util.Collection;
import java.util.HashSet;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
import javax.servlet.ServletContext;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import org.apereo.portal.concurrency.FunctionWithoutResult;
import org.apereo.portal.concurrency.locking.ClusterMutex;
import org.apereo.portal.concurrency.locking.IClusterLockService;
import org.apereo.portal.concurrency.locking.IClusterLockService.LockStatus;
import org.apereo.portal.concurrency.locking.IClusterLockService.TryLockFunctionResult;
import org.apereo.portal.concurrency.locking.LockOptions;
import org.apereo.portal.portlet.dao.IPortletCookieDao;
import org.apereo.portal.portlet.om.IPortalCookie;
import org.apereo.portal.portlet.om.IPortletCookie;
import org.apereo.portal.portlet.om.IPortletWindowId;
import org.joda.time.DateTime;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.orm.hibernate3.HibernateOptimisticLockingFailureException;
import org.springframework.stereotype.Service;
import org.springframework.web.context.ServletContextAware;
import org.springframework.web.util.WebUtils;

/** {@link Service} bean to encapsulate business logic regarding portlet cookie persistence. */
@Service("portletCookieService")
public class PortletCookieServiceImpl implements IPortletCookieService, ServletContextAware {

    /**
     * Name of the {@link HttpSession} attribute used for storing a concurrent map of portlet
     * cookies that do not need to be persisted.
     */
    private static final String SESSION_ATTRIBUTE__SESSION_ONLY_COOKIE_MAP =
            PortletCookieServiceImpl.class.getName() + ".SESSION_ONLY_COOKIE_MAP";
    /**
     * Name of the {@link HttpSession} attribute used to track the value of the {@link
     * IPortalCookie} (useful if the client does not accept cookies). Note: Package access for the
     * test class convenience.
     */
    /*private*/ static final String SESSION_ATTRIBUTE__PORTAL_COOKIE_ID =
            PortletCookieServiceImpl.class.getName() + ".PORTAL_COOKIE_ID";

    private static final String PURGE_LOCK_NAME =
            PortletCookieServiceImpl.class.getName() + ".PURGE_LOCK";

    protected final Logger logger = LoggerFactory.getLogger(getClass());

    private IPortletCookieDao portletCookieDao;
    private IClusterLockService clusterLockService;

    protected static final int DEFAULT_MAX_AGE = (int) TimeUnit.DAYS.toSeconds(365);
    private String cookieName = DEFAULT_PORTAL_COOKIE_NAME;
    private String comment = DEFAULT_PORTAL_COOKIE_COMMENT;
    private String domain = null;
    private String path = "/";
    private int maxAge = DEFAULT_MAX_AGE;
    private int maxAgeUpdateInterval = (int) TimeUnit.MINUTES.toMillis(5);
    private boolean portalCookieAlwaysSecure = false;
    private long purgeExpiredCookiesPeriod = 0;

    @Autowired
    public void setPortletCookieDao(IPortletCookieDao portletCookieDao) {
        this.portletCookieDao = portletCookieDao;
    }

    @Autowired
    public void setClusterLockService(IClusterLockService clusterLockService) {
        this.clusterLockService = clusterLockService;
    }

    @Value(
            "${org.apereo.portal.portlet.container.services.PortletCookieServiceImpl.purgeExpiredCookiesPeriod}")
    public void setPurgeExpiredCookiesPeriod(long purgeExpiredCookiesPeriod) {
        this.purgeExpiredCookiesPeriod = purgeExpiredCookiesPeriod;
    }

    @Override
    public void setServletContext(ServletContext servletContext) {
        this.path = servletContext.getContextPath() + "/";
    }

    /**
     * @param maxAge The max number of seconds the portal cookie should live for. Defaults to 365
     *     days.
     */
    public void setMaxAge(int maxAge) {
        this.maxAge = maxAge;
    }

    /**
     * @param cookieName The name of the cookie to set on the browser. Defaults to {@link
     *     #DEFAULT_PORTAL_COOKIE_NAME} WARNING if you change this in an existing deployment all
     *     existing portal cookies will be orphaned.
     */
    public void setCookieName(String cookieName) {
        this.cookieName = cookieName;
    }

    /**
     * @param comment The comment for the cookie that is set. Defaults to {@link
     *     #DEFAULT_PORTAL_COOKIE_COMMENT}
     */
    public void setComment(String comment) {
        this.comment = comment;
    }

    /** @param domain The domain to set, it is recommended to leave this null. */
    public void setDomain(String domain) {
        this.domain = domain;
    }

    /**
     * @param maxAgeUpdateInterval How frequently (in ms) the maxAge date on the portal cookie
     *     should be updated. Defaults to 5 minutes. Only portal cookies older than 5 minutes will
     *     be updated in the client's browser and the db with a new maxAge
     */
    public void setMaxAgeUpdateInterval(int maxAgeUpdateInterval) {
        this.maxAgeUpdateInterval = maxAgeUpdateInterval;
    }

    /**
     * @param portalCookieAlwaysSecure Set a value of true to set the portal cookie's secure flag to
     *     'true' regardless of the request's secure flag.
     */
    @Value(
            "${org.apereo.portal.portlet.container.services.PortletCookieServiceImpl.portalCookieAlwaysSecure}")
    public void setPortalCookieAlwaysSecure(boolean portalCookieAlwaysSecure) {
        this.portalCookieAlwaysSecure = portalCookieAlwaysSecure;
    }

    /**
     * (non-Javadoc)
     *
     * @see
     *     org.apereo.portal.portlet.container.services.IPortletCookieService#updatePortalCookie(javax.servlet.http.HttpServletRequest,
     *     javax.servlet.http.HttpServletResponse)
     */
    @Override
    public void updatePortalCookie(HttpServletRequest request, HttpServletResponse response) {
        // Get the portal cookie object
        final IPortalCookie portalCookie = this.getOrCreatePortalCookie(request);

        // Create the browser cookie
        final Cookie cookie =
                this.convertToCookie(
                        portalCookie, this.portalCookieAlwaysSecure || request.isSecure());

        // Update the expiration date of the portal cookie stored in the DB if the update interval
        // has passed
        final DateTime expires = portalCookie.getExpires();
        if (DateTime.now()
                .minusMillis(this.maxAgeUpdateInterval)
                .isAfter(expires.minusSeconds(this.maxAge))) {
            try {
                this.portletCookieDao.updatePortalCookieExpiration(
                        portalCookie, cookie.getMaxAge());
            } catch (HibernateOptimisticLockingFailureException e) {
                // Especially with ngPortal UI multiple requests for individual portlet content may
                // come at
                // the same time.  Sometimes another thread updated the portal cookie between our
                // dao fetch and
                // dao update.  If this happens, simply ignore the update since another thread has
                // already
                // made the update.
                logger.debug(
                        "Attempted to update expired portal cookie but another thread beat me to it."
                                + " Ignoring update since the other thread handled it.");
                return;
            }

            // Update expiration dates of portlet cookies stored in session
            removeExpiredPortletCookies(request);
        }
        // Update the cookie in the users browser
        response.addCookie(cookie);
    }

    /**
     * Remove expired session only portlet cookies.
     *
     * @param request
     */
    protected void removeExpiredPortletCookies(HttpServletRequest request) {
        Map<String, SessionOnlyPortletCookieImpl> sessionOnlyCookies =
                getSessionOnlyPortletCookieMap(request);
        for (Entry<String, SessionOnlyPortletCookieImpl> entry : sessionOnlyCookies.entrySet()) {
            String key = entry.getKey();
            SessionOnlyPortletCookieImpl sessionOnlyCookie = entry.getValue();
            if (sessionOnlyCookie.getExpires().isBeforeNow()) {
                sessionOnlyCookies.remove(key);
            }
        }
    }

    @Override
    public Cookie[] getAllPortletCookies(
            HttpServletRequest request, IPortletWindowId portletWindowId) {
        final IPortalCookie portalCookie = this.getPortalCookie(request);

        // Get the cookies from the servlet request
        Cookie[] servletCookies = request.getCookies();
        if (servletCookies == null) {
            servletCookies = new Cookie[0];
        } else if (portalCookie != null) {
            for (int i = 0; i < servletCookies.length; i++) {
                if (servletCookies[i].getName().equals(this.cookieName)) {
                    // replace cookie in the array with converted IPortalCookie (so secure, domain,
                    // path, maxAge are set)
                    servletCookies[i] =
                            convertToCookie(
                                    portalCookie,
                                    this.portalCookieAlwaysSecure || request.isSecure());
                }
            }
        }

        // Get cookies that have been set by portlets, suppressing expired
        Set<IPortletCookie> portletCookies = new HashSet<IPortletCookie>();
        if (portalCookie != null) {
            for (IPortletCookie portletCookie : portalCookie.getPortletCookies()) {
                if (portletCookie.getExpires().isAfterNow()) {
                    portletCookies.add(portletCookie);
                }
            }
        }

        // finally get portlet cookies from session (all maxAge -1)
        Map<String, SessionOnlyPortletCookieImpl> sessionOnlyPortletCookieMap =
                getSessionOnlyPortletCookieMap(request);
        Collection<SessionOnlyPortletCookieImpl> sessionOnlyCookies =
                sessionOnlyPortletCookieMap.values();

        // Merge into a single array
        final Cookie[] cookies =
                new Cookie
                        [servletCookies.length + portletCookies.size() + sessionOnlyCookies.size()];
        System.arraycopy(servletCookies, 0, cookies, 0, servletCookies.length);

        int cookieIdx = servletCookies.length;
        for (final IPortletCookie portletCookie : portletCookies) {
            final Cookie cookie = portletCookie.toCookie();
            cookies[cookieIdx++] = cookie;
        }
        for (SessionOnlyPortletCookieImpl sessionOnlyCookie : sessionOnlyCookies) {
            cookies[cookieIdx++] = sessionOnlyCookie.toCookie();
        }

        return cookies;
    }

    @Override
    public void addCookie(
            HttpServletRequest request, IPortletWindowId portletWindowId, Cookie cookie) {
        final IPortalCookie portalCookie = this.getOrCreatePortalCookie(request);
        if (cookie.getMaxAge() < 0) {
            // persist only in the session
            Map<String, SessionOnlyPortletCookieImpl> sessionOnlyPortletCookies =
                    getSessionOnlyPortletCookieMap(request);
            SessionOnlyPortletCookieImpl sessionOnlyCookie =
                    new SessionOnlyPortletCookieImpl(cookie);
            sessionOnlyPortletCookies.put(cookie.getName(), sessionOnlyCookie);
        } else if (cookie.getMaxAge() == 0) {
            // delete the cookie from the session, if present
            Map<String, SessionOnlyPortletCookieImpl> sessionOnlyPortletCookies =
                    getSessionOnlyPortletCookieMap(request);
            SessionOnlyPortletCookieImpl existing =
                    sessionOnlyPortletCookies.remove(cookie.getName());
            if (null == existing) {
                // returning null from map#remove means cookie wasn't in the session, trigger
                // portletCookieDao update
                this.portletCookieDao.addOrUpdatePortletCookie(portalCookie, cookie);
            }
        } else {
            Map<String, SessionOnlyPortletCookieImpl> sessionOnlyPortletCookies =
                    getSessionOnlyPortletCookieMap(request);
            sessionOnlyPortletCookies.remove(cookie.getName());
            // update the portletCookieDao regardless
            this.portletCookieDao.addOrUpdatePortletCookie(portalCookie, cookie);
        }
    }

    @Override
    public boolean purgeExpiredCookies() {
        try {
            final long purgeExpiredLastRunDelay = (long) (purgeExpiredCookiesPeriod * .95);
            final TryLockFunctionResult<Object> result =
                    this.clusterLockService.doInTryLock(
                            PURGE_LOCK_NAME,
                            LockOptions.builder().lastRunDelay(purgeExpiredLastRunDelay),
                            new FunctionWithoutResult<ClusterMutex>() {
                                @Override
                                protected void applyWithoutResult(ClusterMutex input) {
                                    portletCookieDao.purgeExpiredCookies(maxAge);
                                }
                            });
            return result.getLockStatus() == LockStatus.EXECUTED;
        } catch (InterruptedException e) {
            logger.warn("Interrupted while purging expired cookies", e);
            Thread.currentThread().interrupt();
            return false;
        }
    }

    /**
     * Get the {@link Map} of {@link SessionOnlyPortletCookieImpl}s stored in the {@link
     * HttpSession} specifically used for storing {@link SessionOnlyPortletCookieImpl}s with a
     * maxAge equal to -1. Will create the map if it doesn't yet exist.
     *
     * @param request
     * @return
     */
    @SuppressWarnings("unchecked")
    protected Map<String, SessionOnlyPortletCookieImpl> getSessionOnlyPortletCookieMap(
            final HttpServletRequest request) {
        final HttpSession session = request.getSession();
        synchronized (WebUtils.getSessionMutex(session)) {
            Map<String, SessionOnlyPortletCookieImpl> sessionOnlyPortletCookies =
                    (Map<String, SessionOnlyPortletCookieImpl>)
                            session.getAttribute(SESSION_ATTRIBUTE__SESSION_ONLY_COOKIE_MAP);
            if (sessionOnlyPortletCookies == null) {
                sessionOnlyPortletCookies =
                        new ConcurrentHashMap<String, SessionOnlyPortletCookieImpl>();
                session.setAttribute(
                        SESSION_ATTRIBUTE__SESSION_ONLY_COOKIE_MAP, sessionOnlyPortletCookies);
            }
            return sessionOnlyPortletCookies;
        }
    }
    /**
     * Convert the {@link IPortalCookie} into a servlet {@link Cookie}.
     *
     * @param portalCookie
     * @return
     */
    protected Cookie convertToCookie(IPortalCookie portalCookie, boolean secure) {
        final Cookie cookie = new Cookie(this.cookieName, portalCookie.getValue());

        // Set the cookie's fields
        cookie.setComment(this.comment);
        cookie.setMaxAge(this.maxAge);
        cookie.setSecure(secure);
        cookie.setHttpOnly(true);
        if (this.domain != null) {
            cookie.setDomain(this.domain);
        }

        cookie.setPath(this.path);

        return cookie;
    }

    /**
     * Check the {@link HttpSession} for the ID of the Portal Cookie. This is useful if the customer
     * does not wish to accept cookies.
     *
     * @param session
     * @return
     */
    protected IPortalCookie locatePortalCookieInSession(HttpSession session) {
        synchronized (WebUtils.getSessionMutex(session)) {
            final String portalCookieId =
                    (String) session.getAttribute(SESSION_ATTRIBUTE__PORTAL_COOKIE_ID);
            if (portalCookieId == null) {
                return null;
            }
            IPortalCookie portalCookie = this.portletCookieDao.getPortalCookie(portalCookieId);
            return portalCookie;
        }
    }

    /**
     * Locate the existing {@link IPortalCookie} with the request, or create a new one.
     *
     * @param request
     * @return the {@link IPortalCookie} - never null
     */
    protected IPortalCookie getOrCreatePortalCookie(HttpServletRequest request) {
        IPortalCookie result = null;

        // first check in request
        final Cookie cookie = this.getCookieFromRequest(this.cookieName, request);
        if (cookie != null) {
            // found a potential cookie, call off to the dao
            final String value = cookie.getValue();
            result = this.portletCookieDao.getPortalCookie(value);
        }

        // still null? check in the session
        if (result == null) {
            result = locatePortalCookieInSession(request.getSession());
        }
        // if by this point we still haven't found the portal cookie, create one
        if (result == null) {
            result = this.portletCookieDao.createPortalCookie(this.maxAge);
            // store the portal cookie value value in the session
            HttpSession session = request.getSession();
            synchronized (WebUtils.getSessionMutex(session)) {
                session.setAttribute(SESSION_ATTRIBUTE__PORTAL_COOKIE_ID, result.getValue());
            }
        }

        return result;
    }

    /**
     * Get THE {@link IPortalCookie} from the {@link HttpServletRequest}, if it exists. Gracefully
     * returns null if not in the request.
     *
     * @param request
     * @return
     */
    protected IPortalCookie getPortalCookie(HttpServletRequest request) {
        final Cookie cookie = this.getCookieFromRequest(this.cookieName, request);
        if (cookie == null) {
            // check the session
            IPortalCookie portalCookieInSession = locatePortalCookieInSession(request.getSession());
            if (null != portalCookieInSession) {
                return portalCookieInSession;
            }

            return null;
        }

        final String value = cookie.getValue();
        return this.portletCookieDao.getPortalCookie(value);
    }

    /**
     * Attempts to retrieve the {@link Cookie} with the specified name from the {@link
     * HttpServletRequest}.
     *
     * <p>Returns the {@link Cookie} if a match is found in the request, otherwise gracefully
     * returns null.
     *
     * @param name
     * @param request
     * @return
     */
    protected Cookie getCookieFromRequest(String name, HttpServletRequest request) {
        final Cookie[] cookies = request.getCookies();
        if (cookies == null) { // getCookies() returns null if there aren't any
            return null;
        }

        for (final Cookie cookie : cookies) {
            if (name.equals(cookie.getName())) {
                return cookie;
            }
        }

        return null;
    }
}
